--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 9f8ffbfc9b68e5b6b0100dbadbd6857059cfed0d
Parents : c15af0f
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-04T23:32:19-05:00
feat(rnstatus): update RNStatusHandler with new formatting functions and interface discovery integration
Changes
3 files changed, 154 insertions(+), 42 deletions(-)
Diff
diff --git a/meshchatx/src/backend/rnstatus_handler.py b/meshchatx/src/backend/rnstatus_handler.py
index 82c8d6b0..424408b2 100644
--- a/meshchatx/src/backend/rnstatus_handler.py
+++ b/meshchatx/src/backend/rnstatus_handler.py
@@ -3,6 +3,7 @@ import time
from typing import Any
import RNS
+from RNS.Discovery import InterfaceDiscovery
def size_str(num, suffix="B"):
@@ -24,6 +25,63 @@ def size_str(num, suffix="B"):
return f"{num:.2f}{last_unit}{suffix}"
+def fmt_per_second(value: Any) -> str | None:
+ if value is None:
+ return None
+ try:
+ x = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ ax = abs(x)
+ if ax == 0:
+ return "0"
+ if ax >= 100:
+ return f"{x:.1f}"
+ if ax >= 1:
+ return f"{x:.2f}"
+ return f"{x:.3g}"
+
+
+def fmt_packet_count(value: Any) -> str | None:
+ if value is None:
+ return None
+ try:
+ x = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ return f"{int(round(x)):,}"
+
+
+def fmt_percentage(value: Any) -> str | None:
+ if value is None:
+ return None
+ try:
+ x = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ ax = abs(x)
+ if ax >= 100:
+ return f"{x:.1f}"
+ if ax >= 10:
+ return f"{x:.2f}"
+ return f"{x:.3g}"
+
+
+def stat_name_matches_discovered(stat_name: str, discovered_list: list) -> bool:
+ if not stat_name or not discovered_list:
+ return False
+ for d in discovered_list:
+ if not isinstance(d, dict):
+ continue
+ ro = d.get("reachable_on")
+ if ro and str(ro) in stat_name:
+ return True
+ dn = d.get("name")
+ if dn and str(dn) and str(dn) in stat_name:
+ return True
+ return False
+
+
class RNStatusHandler:
def __init__(self, reticulum_instance):
self.reticulum = reticulum_instance
@@ -56,6 +114,12 @@ class RNStatusHandler:
"link_count": link_count,
}
+ discovered_list: list = []
+ with contextlib.suppress(Exception):
+ discovered_list = InterfaceDiscovery(
+ discover_interfaces=False,
+ ).list_discovered_interfaces()
+
blackhole_enabled = False
blackhole_sources = []
blackhole_count = 0
@@ -139,6 +203,7 @@ class RNStatusHandler:
formatted_if: dict[str, Any] = {
"name": name,
"status": "Up" if ifstat.get("status") else "Down",
+ "discovered": stat_name_matches_discovered(name, discovered_list),
}
mode = ifstat.get("mode")
@@ -165,9 +230,9 @@ class RNStatusHandler:
formatted_if["tx_bytes"] = ifstat["txb"]
formatted_if["tx_bytes_str"] = size_str(ifstat["txb"])
if "rxs" in ifstat:
- formatted_if["rx_packets"] = ifstat["rxs"]
+ formatted_if["rx_packets"] = fmt_packet_count(ifstat["rxs"])
if "txs" in ifstat:
- formatted_if["tx_packets"] = ifstat["txs"]
+ formatted_if["tx_packets"] = fmt_packet_count(ifstat["txs"])
if "clients" in ifstat and ifstat["clients"] is not None:
formatted_if["clients"] = ifstat["clients"]
@@ -194,29 +259,29 @@ class RNStatusHandler:
if "airtime_short" in ifstat and "airtime_long" in ifstat:
formatted_if["airtime"] = {
- "short": ifstat["airtime_short"],
- "long": ifstat["airtime_long"],
+ "short": fmt_percentage(ifstat["airtime_short"]),
+ "long": fmt_percentage(ifstat["airtime_long"]),
}
if "channel_load_short" in ifstat and "channel_load_long" in ifstat:
formatted_if["channel_load"] = {
- "short": ifstat["channel_load_short"],
- "long": ifstat["channel_load_long"],
+ "short": fmt_percentage(ifstat["channel_load_short"]),
+ "long": fmt_percentage(ifstat["channel_load_long"]),
}
if "peers" in ifstat and ifstat["peers"] is not None:
formatted_if["peers"] = ifstat["peers"]
if "incoming_announce_frequency" in ifstat:
- formatted_if["incoming_announce_frequency"] = ifstat[
- "incoming_announce_frequency"
- ]
+ formatted_if["incoming_announce_frequency"] = fmt_per_second(
+ ifstat["incoming_announce_frequency"]
+ )
if "outgoing_announce_frequency" in ifstat:
- formatted_if["outgoing_announce_frequency"] = ifstat[
- "outgoing_announce_frequency"
- ]
+ formatted_if["outgoing_announce_frequency"] = fmt_per_second(
+ ifstat["outgoing_announce_frequency"]
+ )
if "held_announces" in ifstat:
- formatted_if["held_announces"] = ifstat["held_announces"]
+ formatted_if["held_announces"] = fmt_packet_count(ifstat["held_announces"])
if "ifac_netname" in ifstat and ifstat["ifac_netname"] is not None:
formatted_if["network_name"] = ifstat["ifac_netname"]
diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
index e10e05e5..63720188 100644
--- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
+++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
@@ -4,7 +4,7 @@
>
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
- <div class="glass-card space-y-5">
+ <div class="glass-card space-y-5 rounded-2xl border border-slate-200/70 p-5 dark:border-zinc-700/80">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Network Diagnostics
@@ -17,27 +17,29 @@
</div>
</div>
- <div class="flex flex-wrap gap-2">
+ <div class="flex flex-wrap items-center gap-2">
<button
type="button"
- class="primary-chip px-4 py-2 text-sm"
+ class="primary-chip inline-flex items-center gap-2 px-4 py-2 text-sm"
:disabled="isLoading"
@click="refreshStatus"
>
<MaterialDesignIcon
icon-name="refresh"
- class="w-4 h-4"
+ class="h-4 w-4 shrink-0"
:class="{ 'animate-spin-reverse': isLoading }"
/>
Refresh
</button>
- <label class="flex items-center gap-2 cursor-pointer secondary-chip px-4 py-2 text-sm">
+ <label
+ class="secondary-chip inline-flex cursor-pointer items-center gap-2 px-4 py-2 text-sm"
+ >
<input v-model="includeLinkStats" type="checkbox" class="rounded" />
<span>Include Link Stats</span>
</label>
- <div class="flex items-center gap-2">
- <label class="text-sm text-gray-700 dark:text-gray-300">Sort by:</label>
- <select v-model="sorting" class="input-field text-sm">
+ <div class="flex min-w-0 flex-wrap items-center gap-2">
+ <label class="shrink-0 text-sm text-gray-700 dark:text-gray-300">Sort by:</label>
+ <select v-model="sorting" class="input-field min-w-[10rem] text-sm">
<option value="">None</option>
<option value="bitrate">Bitrate</option>
<option value="rx">RX Bytes</option>
@@ -48,27 +50,34 @@
</div>
</div>
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-if="linkCount !== null"
- class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
+ class="rounded-xl border border-blue-200/80 bg-blue-50/90 p-4 text-blue-800 dark:border-blue-800/50 dark:bg-blue-950/30 dark:text-blue-200"
>
- <div class="font-semibold">Active Links: {{ linkCount }}</div>
+ <div class="text-sm font-semibold">
+ Active Links: {{ formatInt(linkCount) }}
+ </div>
</div>
<div
v-if="blackholeEnabled !== null"
- class="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300"
+ class="rounded-xl border border-purple-200/80 bg-purple-50/90 p-4 text-purple-900 dark:border-purple-800/50 dark:bg-purple-950/30 dark:text-purple-100"
>
- <div class="font-semibold flex justify-between items-center">
+ <div class="flex flex-wrap items-center justify-between gap-2 font-semibold">
<span>Blackhole: {{ blackholeEnabled ? "Publishing" : "Active" }}</span>
- <span class="text-sm opacity-80"> {{ blackholeCount }} Identities </span>
+ <span class="text-sm font-normal opacity-90">
+ {{ formatInt(blackholeCount) }} Identities
+ </span>
</div>
</div>
</div>
</div>
- <div v-if="blackholeSources.length > 0" class="glass-card space-y-3">
+ <div
+ v-if="blackholeSources.length > 0"
+ class="glass-card space-y-3 rounded-2xl border border-slate-200/70 p-5 dark:border-zinc-700/80"
+ >
<div class="font-semibold text-lg text-gray-900 dark:text-white">Blackhole Sources</div>
<div class="grid gap-2">
<div
@@ -88,22 +97,42 @@
No interfaces found. Click refresh to load status.
</div>
- <div v-for="iface in interfaces" :key="iface.name" class="glass-card space-y-3">
- <div class="flex items-center justify-between">
- <div class="font-semibold text-lg text-gray-900 dark:text-white">{{ iface.name }}</div>
+ <div
+ v-for="iface in interfaces"
+ :key="iface.name"
+ class="glass-card overflow-hidden rounded-2xl border border-slate-200/70 dark:border-zinc-700/80"
+ >
+ <div
+ class="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 bg-slate-50/60 px-4 py-4 dark:border-zinc-800/80 dark:bg-zinc-900/40 sm:px-5"
+ >
+ <div class="min-w-0 flex-1 space-y-2">
+ <div class="flex flex-wrap items-center gap-2">
+ <h3
+ class="break-words text-base font-semibold leading-snug text-gray-900 dark:text-white sm:text-lg"
+ >
+ {{ iface.name }}
+ </h3>
+ <span
+ v-if="iface.discovered"
+ class="inline-flex shrink-0 items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900 dark:bg-amber-900/45 dark:text-amber-100"
+ >
+ Discovered
+ </span>
+ </div>
+ </div>
<span
:class="[
iface.status === 'Up'
- ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200'
- : 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
- 'rounded-full px-3 py-1 text-xs font-semibold',
+ ? 'bg-green-100 text-green-800 dark:bg-green-900/45 dark:text-green-100'
+ : 'bg-red-100 text-red-800 dark:bg-red-900/45 dark:text-red-100',
+ 'shrink-0 rounded-full px-3 py-1 text-xs font-semibold',
]"
>
{{ iface.status }}
</span>
</div>
- <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
+ <div class="grid gap-x-6 gap-y-4 p-4 text-sm sm:p-5 md:grid-cols-2 lg:grid-cols-3">
<div v-if="iface.mode">
<div class="text-gray-500 dark:text-gray-400">Mode</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.mode }}</div>
@@ -122,19 +151,25 @@
</div>
<div v-if="iface.rx_packets !== undefined">
<div class="text-gray-500 dark:text-gray-400">RX Packets</div>
- <div class="font-semibold text-gray-900 dark:text-white">{{ iface.rx_packets }}</div>
+ <div class="font-semibold tabular-nums text-gray-900 dark:text-white">
+ {{ iface.rx_packets }}
+ </div>
</div>
<div v-if="iface.tx_packets !== undefined">
<div class="text-gray-500 dark:text-gray-400">TX Packets</div>
- <div class="font-semibold text-gray-900 dark:text-white">{{ iface.tx_packets }}</div>
+ <div class="font-semibold tabular-nums text-gray-900 dark:text-white">
+ {{ iface.tx_packets }}
+ </div>
</div>
<div v-if="iface.clients !== undefined">
<div class="text-gray-500 dark:text-gray-400">Clients</div>
- <div class="font-semibold text-gray-900 dark:text-white">{{ iface.clients }}</div>
+ <div class="font-semibold text-gray-900 dark:text-white">{{ formatInt(iface.clients) }}</div>
</div>
<div v-if="iface.peers !== undefined">
<div class="text-gray-500 dark:text-gray-400">Peers</div>
- <div class="font-semibold text-gray-900 dark:text-white">{{ iface.peers }} reachable</div>
+ <div class="font-semibold text-gray-900 dark:text-white">
+ {{ formatInt(iface.peers) }} reachable
+ </div>
</div>
<div v-if="iface.noise_floor">
<div class="text-gray-500 dark:text-gray-400">Noise Floor</div>
@@ -159,7 +194,7 @@
<div v-if="iface.battery_percent !== undefined">
<div class="text-gray-500 dark:text-gray-400">Battery</div>
<div class="font-semibold text-gray-900 dark:text-white">
- {{ iface.battery_percent }}%<span v-if="iface.battery_state">
+ {{ formatInt(iface.battery_percent) }}%<span v-if="iface.battery_state">
({{ iface.battery_state }})</span
>
</div>
@@ -170,13 +205,13 @@
</div>
<div v-if="iface.incoming_announce_frequency !== undefined">
<div class="text-gray-500 dark:text-gray-400">Incoming Announces</div>
- <div class="font-semibold text-gray-900 dark:text-white">
+ <div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.incoming_announce_frequency }}/s
</div>
</div>
<div v-if="iface.outgoing_announce_frequency !== undefined">
<div class="text-gray-500 dark:text-gray-400">Outgoing Announces</div>
- <div class="font-semibold text-gray-900 dark:text-white">
+ <div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.outgoing_announce_frequency }}/s
</div>
</div>
@@ -231,6 +266,16 @@ export default {
this.refreshStatus();
},
methods: {
+ formatInt(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ const n = Number(value);
+ if (Number.isNaN(n)) {
+ return String(value);
+ }
+ return n.toLocaleString();
+ },
async refreshStatus() {
this.isLoading = true;
try {
diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js
index ac528e50..d0ce96cc 100644
--- a/tests/frontend/RNStatusPage.test.js
+++ b/tests/frontend/RNStatusPage.test.js
@@ -19,6 +19,7 @@ describe("RNStatusPage.vue", () => {
{
name: "Interface 1",
status: "Up",
+ discovered: true,
bitrate: "100 bps",
rx_bytes_str: "10 B",
tx_bytes_str: "5 B",
@@ -62,6 +63,7 @@ describe("RNStatusPage.vue", () => {
expect(wrapper.text()).toContain("RNStatus - Network Status");
expect(wrapper.text()).toContain("Interface 1");
+ expect(wrapper.text()).toContain("Discovered");
expect(wrapper.text()).toContain("Active Links: 5");
expect(wrapper.text()).toContain("Blackhole: Publishing");
expect(wrapper.text()).toContain("src1");
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────